/*
 *  Copyright (C) 2004 Cidero, Inc.
 *
 *  Permission is hereby granted to any person obtaining a copy of 
 *  this software to use, copy, modify, merge, publish, and distribute
 *  the software for any non-commercial purpose, subject to the
 *  following conditions:
 *  
 *  The above copyright notice and this permission notice shall be included
 *  in all copies or substantial portions of the Software.
 *
 *  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 
 *  OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 *  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 
 *  THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 *  LIABILITY IN CONNECTION WITH THE SOFTWARE.
 * 
 *  File: $RCSfile: AbstractService.java,v $
 *
 */
package com.cidero.upnp;

import java.lang.reflect.*;
import java.util.logging.Logger;
import java.io.ByteArrayInputStream;
import java.io.IOException;

import org.apache.xerces.parsers.SAXParser;
import org.xml.sax.Attributes;
import org.xml.sax.ContentHandler;
import org.xml.sax.InputSource;
import org.xml.sax.Locator;
import org.xml.sax.SAXException;
import org.xml.sax.XMLReader;

import org.cybergarage.upnp.*;
import org.cybergarage.upnp.control.ActionListener;
import org.cybergarage.upnp.control.QueryListener;
import org.cybergarage.upnp.device.InvalidDescriptionException;
import org.cybergarage.upnp.event.EventListener;

import com.cidero.util.XMLUtil;

/**
 *  Base class for service-specific 'helper' classes that extend the 
 *  functionality of the CyberLink package's Service class. 
 *
 *  From the device perspective, this class provides an automatic dispatcher
 *  for incoming UPnP actions to methods with the same name as the
 *  action.  So if, for example, your 'MyDeviceAVTransport' service 
 *  defines the method 'actionPlay', the method will be automatically
 *  invoked as long as MyDeviceAVTransport is derived from this class.
 *
 *  From the control point perspective, this class provides an automatic
 *  dispatcher for incoming events (notification of changes to a device's
 *  state variables). The local copy of the state variable is automatically
 *  updated, and if there is a method of the name 'event<StateVarName>' in
 *  the derived class, it is invoked (in that order)
 *
 */
public abstract class AbstractService
                implements ActionListener,
                           EventListener,
                           QueryListener,
                           ContentHandler
{
  private static Logger logger = Logger.getLogger("com.cidero.upnp");

  /**
   * Underlying Cybergarage UPnP Device,Service objects
   */
  Device  device = null;
  Service service = null;

  private long seq;   // event seq number

  /**
   * Some SOAP messages have non-escaped '&' characters, which fault
   * out the Apache parser. If detected in past SOAP packets (flag true),
   * fix'em up prior to first XML parse
   */
  boolean escapeNonEntityAmpersands = false;   // Assume XML ok
  
  /**
   * Constructor. Setup all the action/query listeners for this service,
   * using the CLink device service with the same name as this service
   *
   * @param      CLink device object
   *
   * @exception  Throws InvalidDescriptionException if device has no 
   *             matching service
   */
  public AbstractService( Device device )
    throws InvalidDescriptionException
  {
    // Get underlying Cybergarage service object for this service and 
    // install actionControlReceived() 'dispatcher' method below for 
    // all actions

    this.device = device;
    
    ServiceList serviceList = device.getServiceList();
    if (serviceList == null)
    {
      logger.severe("Null service list in service constructor");
      throw new InvalidDescriptionException("Null device service list");
    }
    
    logger.fine("This service type = " + getServiceType());

    int n;
    for (n = 0; n < serviceList.size(); n++)
    {
      Service service = serviceList.getService(n);
      logger.fine("ServiceType = " + service.getServiceType() );
      logger.fine("ServiceId = " + service.getServiceID());

      if( service.isService(getServiceType()) )
      {
        logger.fine("Matching service found ");

        this.service = service;
          
        service.setQueryListener( this );
        service.addEventListener( this );

        ActionList actionList = service.getActionList();
        
        for (int j = 0; j < actionList.size(); j++)
        {
          Action action = actionList.getAction( j );
          action.setActionListener( this );
        }
        break;
      }
      else
      {
        logger.fine("Not a matching service ");
      }
    }

    if (n == serviceList.size())
    {
      throw new InvalidDescriptionException("Device doesn't support service '" + getServiceType() );
    }

    // If this service is being instantiated on the *device* side (as
    // opposed to the control point side), initialize state variables
    if( device.getInstancePerspective() == UPnP.DEVICE )
      initializeStateVariables();
    
  }

  public AbstractService()
  {
  }
  

  /**
   * Get name of this service from derived class. Example:
   *     "urn:schemas-upnp-org:service:AVTransport:1";
   */
  abstract public String getServiceType();

  abstract public void initializeStateVariables();

  public Device getDevice()
  {
    return device;
  }
  public Service getService()
  {
    return service;
  }

  public Action getAction( String actionName )
  {
    if( service == null )
      return null;
    
    return service.getAction( actionName );
  }
  

  public void setStateVariable( String varName, String value )
  {
    StateVariable stateVar = service.getStateVariable( varName );
    if( stateVar == null )
    {
      logger.warning("state variable '" + varName + "' not found" );
      return;
    }
    stateVar.setValue( value );
  }

  public StateVariable getStateVariable( String varName )
  {
    return service.getStateVariable( varName );
  }
  public String getStateVariableValue( String varName )
  {
    return service.getStateVariable( varName ).getValue();
  }

  /**
   * Process an incoming action and invoke a class method of the derived
   * class with the same name as the action.  For example, for the 
   * MediaRenderer action 'Play', a method called 'actionPlay(action)'
   * would be searched for and invoked if it existed.
   *
   * @param action  UPnP action object
   *
   * @return  true if method existed and completed successfully, otherwise
   *          false
   */
  public boolean actionControlReceived(Action action)
  {
//    logger.finer("actionControlReceived: " +action.getName());

    //
    // Convention is for this service to have methods with same name
    // as UPNP action. Look up method by name and invoke it
    //
    Class c = this.getClass();
    Class[] parameterTypes = new Class[] {Action.class};
    Method actionMethod;
    Object[] arguments = new Object[] { action };

    try
    {
      //
      // Action methods are named using the string "action" followed
      // by the UPNP Action name, e.g. "actionPlay". This is to
      // clearly differentiate which methods in derived classes are 
      // action callbacks
      //
      actionMethod = c.getMethod( "action" + action.getName(),
                                  parameterTypes );

      Boolean result = (Boolean) actionMethod.invoke( this, arguments );

      return result.booleanValue();
    }
    catch( NoSuchMethodException e )
    {
      logger.warning("Unsupported action! [" + action.getName() + "]" + e );
    }
    catch( IllegalAccessException e )
    {
      logger.warning("Access denied for method" + e );
    }
    catch( InvocationTargetException e )
    {
      logger.warning("Invocation exception" );
      logger.warning( "Cause: " + e.getCause().toString() );
      e.getCause().printStackTrace();
    }

    return false;

  }

  /**
   * Process an incoming state variable query. Cybergarage CLink API
   * assumes application may be using it's own data model, so CLink
   * doesn't automatically return the current value of the state
   * variable as it exists in the CLink Service object. Instead,
   * an empty version of the state variable (value set to empty
   * string) is passed to this routine. If the variable is a 
   * known state variable, the value should be set and the 
   * routine should return 'true'. At that point, CLink takes
   * over and automatically sends the Query response.
   * If the variable is unknown, this routine should return false.
   *
   * Default implementation below just returns the value of
   * the state variable associated with the service
   * class with the same name as the action.  This is appropriate
   * if the application is using the service's state variables
   * as the 'master state model'
   *
   * @param stateVar    State variable to query, with value set
   *                    to empty string (""). Upon return,
   *                    the value should be set to the current
   *                    value
   *
   * @return  true if variable is a recognized one for this 
   *          service, otherwise false
   *
   * Notes: Use of the query mechanism (querying one variable at a time)
   * is generally discouraged in favor of using available actions that 
   * return logically grouped sets of state variables, in combination with
   * eventing. Getting one variable at a time is too prone to race 
   * conditions between logically grouped variables.
   */
  public boolean queryControlReceived( StateVariable stateVar ) 
  {
    String varName = stateVar.getName();
    logger.fine("queryControlReceived, var = " + varName );

    StateVariable serviceStateVar = service.getStateVariable( varName );
    if( serviceStateVar == null )
      return false;

    stateVar.setValue( serviceStateVar.getValue() );

    return true;
  }

  /**
   * Process an incoming event and invoke a class method of the derived
   * class with the same name as the event.  For example, for an event
   * with name "TransportState", the method 'eventTransportState'
   * would be searched for and invoked if it existed.
   *
   * @param uuid
   * @param seq
   * @param name
   * @param value
   *
   */
  public void eventNotifyReceived( String uuid, long seq, String name,
                                   String value )
  {
    
    logger.info("----------- AbstractService Event Received----------------");
    logger.info("event notify : uuid = " + uuid + ", seq = " + 
                seq + ", name = " + name + ", value =" + value); 
    

    this.seq = seq;
    
    StateVariable serviceStateVar = service.getStateVariable( name );
    if( serviceStateVar == null )
    {
      Device dev = service.getRootDevice();
      
      logger.warning("Event with name '" + name + 
                     "' has no associated state variable in device service's XML description \n" +
                     "Device: " + dev.getFriendlyName() +
                     " ServiceType: " + getServiceType() );
      
      StringBuffer buf = new StringBuffer();
      ServiceStateTable stateTable = service.getServiceStateTable();
      for( int n = 0 ; n < stateTable.size() ; n++ )
      {
        StateVariable knownVar = stateTable.getStateVariable(n);
        buf.append( knownVar.getName() + " " );
      }
      logger.warning("Known variables:\n" + buf.toString() );
      
      // Patch by manually adding the state variable to the service here
      // so the next event for the variable won't generate a warning.
      // This is a hack - the *real* fix is for the device to add the 
      // variable to its service description XML
      logger.warning("Patch - Manually adding state variable: " + 
                     name );
      service.addStateVariable( name, "string" );
      return;
    }
    // Set the local instance of the corresponding state variable. Since
    // we're on the control point side use 'setValueNoEvent' since we're
    // an event *receiver*, not an event sender
    serviceStateVar.setValueNoEvent( value );
    
    //
    // Convention is for this service to have methods with same name
    // as UPNP event. Look up method by name and invoke it
    //
    Class c = this.getClass();
    Class[] parameterTypes = new Class[] {String.class};
    Method eventMethod;
    Object[] arguments = new Object[] { value };

    try
    {
      eventMethod = c.getMethod( "event" + name,
                                  parameterTypes );

      eventMethod.invoke( this, arguments );
    }
    catch( NoSuchMethodException e )
    {
      logger.finest( "No event method for stateVar '" + name + "'" );
    }
    catch( IllegalAccessException e )
    {
      logger.warning( "event processing exception: " + e );
    }
    catch( InvocationTargetException e )
    {
      logger.warning( "event processing exception: " + e);
      logger.warning( "Cause: " + e.getCause().toString() );
      e.getCause().printStackTrace();
    }

    return;
  }

  /** 
   *
   *  For the AVTransport and RendingControl services, a two-tiered 
   *  event scheme using the 'LastChange' state variable exists that
   *  requires an extra level of XML parsing/dispatching (yech - this
   *  was unnecessary, IMHO).   
   *  
   *  This dispatcher code deals with it, and makes it appear to the
   *  application that all the state variables passed via the LastChange
   *  mechanism are 'normal' evented state variables. 
   *
   *  Sample of LastChange value XML syntax:
   *
   *  <Event>
   *    <InstanceID val="0">
   *      <Volume val="76" />
   *      <Mute val="0" />
   *    </InstanceID>
   *  </Event>
   *
   *  Special cases:
   *
   *  RenderingControl has a single 'Volume' state variable, but the 
   *  the SetVolume/GetVolume actions have a channel argument ('Master', 
   *  "LF", "RF", "LR", "RR"....)  This results in the following 'LastChange'
   *  syntax:
   *
   *  <Event>
   *    <InstanceID val="0">
   *      <Volume val="76" channel="LF" />
   *    </InstanceID>
   *  </Event>
   * 
   *  This case breaks the one state variable per state item rule, it 
   *  seems. In other words, the service has only a single 'Volume' 
   *  state variable, but there are really a number of volume states,
   *  one for each channel.  This makes it hard to use the service 
   *  state variable as the underlying data model for device state, 
   *  which would be the cleanest way to implement things...
   */ 

  XMLReader parser = null;

  public void eventLastChange( String value )
  {
    logger.fine("--------- eventLastChange --------------");
    logger.fine("parsing XML with value = " + value); 

    if( parser == null )
    {
      parser = new SAXParser();
      parser.setContentHandler( this );
    }
    
    // If there were XML errors due to unescaped Entity refs in the past
    // apply patch up front to avoid parse/patch/reparse overhead. This
    // also prevents repeated dire-sounding error messages.
    if( escapeNonEntityAmpersands )
      value = XMLUtil.escapeNonEntityAmpersands( value );

    byte[] xmlByteArray = value.getBytes();
    int length = xmlByteArray.length;

    // trim off any trailing bytes after closing '>'  (some UPnP 
    // implementations leave some extraneous non-printable characters at
    // end of XML packet, and SAX parser throws exception)
    for( int n = xmlByteArray.length-1 ; n > 0 ; n-- )
    {
      if( xmlByteArray[n] == '>' )
        break;
      length--;
    }

    ByteArrayInputStream xmlStream =
      new ByteArrayInputStream( xmlByteArray, 0, length );
    
    try
    {
      InputSource inputSource = new InputSource( xmlStream );
      inputSource.setEncoding( "UTF-8" );
      parser.parse( inputSource );
    }
    catch( IOException e )
    {
      logger.warning("Error parsing XML" + e );
    }
    catch( SAXException e )
    {
      if( escapeNonEntityAmpersands )  
      {
        // Already tried patch and it didn't work
        logger.warning("Error parsing XML" + e );
        return;
      }
      
      // Fix for some renderers that leave '&' unescaped
      logger.warning("Error parsing XML - trying to patch XML: " + e );
      value = XMLUtil.escapeNonEntityAmpersands( value );
      xmlByteArray = value.getBytes();
      length = xmlByteArray.length;
      for( int n = xmlByteArray.length-1 ; n > 0 ; n-- )
      {
        if( xmlByteArray[n] == '>' )
          break;
        length--;
      }
      xmlStream = new ByteArrayInputStream( xmlByteArray, 0, length );

      try
      {
        InputSource inputSource = new InputSource( xmlStream );
        inputSource.setEncoding( "UTF-8" );
        parser.parse( inputSource );

        // Fix worked - latch success so next time we can be smart and 
        // apply patch prior to first XML parse
        escapeNonEntityAmpersands = true;  
        logger.warning("XML entity reference patch suceeded - will automatically apply to all future 'lastChange' event packets");
      }
      catch( IOException e2 )
      {
        logger.warning("Error parsing XML" + e2 );
        return;
      }
      catch( SAXException e2 )
      {
        logger.warning("Unfixable error parsing XML: " + e2 );
        return;
      }
    }

    //
    //  Invoke routine at end of LastChange data. Higher level apps should
    //  override this if they want to fire off a model-changed notify event
    // 
    eventLastChangeEnd();

  }

  /**
   *  Default at end of LastChange is no-op
   */
  public void eventLastChangeEnd()
  {
    ;
  }


  /** 
   *  Parser interface methods
   */

  public void setDocumentLocator( Locator locator )
  {
    //logger.finest("setDocumentLocator:" + "\n" );
  }

  public void startDocument() throws SAXException
  {
    //logger.finest("startDocument:" + "\n" );
  }

  public void endDocument() throws SAXException
  {
    //logger.finest("endDocument:" + "\n" );
  }

  public void processingInstruction( String target, String data )
    throws SAXException
  {
    //logger.finest("processingInstruction: target: " + target +
    //                       " data: " + data );
  }

  public void startPrefixMapping( String prefix, String uri )
    throws SAXException
  {
    //logger.finest("startPrefixMapping: prefix: " + prefix +
    //                  " uri: " + uri );
  }

  public void endPrefixMapping( String prefix )
    throws SAXException
  {
    //logger.finest("endPrefixMapping: prefix: " + prefix );
  }

  public void startElement( String namespaceURI, String localName,
                            String rawName, Attributes atts )
    throws SAXException
  {
    //    logger.finest("startElement: " +
    //                " localName: " + localName +
    //                " rawName: " + rawName );

    String value = null;
    
    for( int n = 0 ; n < atts.getLength() ; n++ )
    {
      //      logger.fine("  Attr: " + atts.getLocalName(n) +
      //                         " = " + atts.getValue(n) );
      if( atts.getLocalName(n).equals("val") )
        value = atts.getValue(n);
    }

    // skip root 'Event' &  'InstanceID' node
    if( localName.equals("Event") || localName.equals("InstanceID") )
      return;

    // Some implementations don't specify 'val' attribute if it's empty
    // Use null string in this case
    if( value == null )
    {
      //      logger.info("No 'val' attribute for elem " + rawName +
      //                  " using empty string");
      value = "";
    }
    
    eventNotifyReceived( "LastChangeUUID", seq, localName, value );
    
  }

  public void endElement( String namespaceURI, String localName,
                          String rawName )
    throws SAXException
  {
    //    System.out.println("endElement: " +
    //                       " localName: " + localName +
    //                       " rawName: " + rawName  );
  }

  public void characters( char[] ch, int start, int end )
    throws SAXException
  {
    //    String s = new String( ch, start, end );
    //    System.out.println( "characters: " + s );
  }

  public void ignorableWhitespace( char[] ch, int start, int end )
    throws SAXException
  {
    //    String s = new String( ch, start, end );
    //    System.out.println( "whitespace: [" + s + "]" );
  }
  
  public void skippedEntity( String name )
    throws SAXException
  {
    //    System.out.println("skippedEntity: name: " + name );
  }
  

  abstract public String errorToString(int code);


}
